Skip to content

fix(ci): drop invalid workflows permission scope from branch-cleanup - #1420

Merged
groupthinking merged 3 commits into
mainfrom
claude/clever-heisenberg-e6y7a1
Aug 7, 2026
Merged

fix(ci): drop invalid workflows permission scope from branch-cleanup#1420
groupthinking merged 3 commits into
mainfrom
claude/clever-heisenberg-e6y7a1

Conversation

@groupthinking

@groupthinking groupthinking commented Aug 7, 2026

Copy link
Copy Markdown
Owner

Canonical issue

Closes #1405

Outcome

.github/workflows/branch-cleanup.yml loads. It has never loaded before.

The permissions: block declared workflows: write. That is not a GitHub Actions permission scope, so GitHub rejected the file outright and created an instantly-failed run on every push to every branch — including main. Removing one line makes the file parseable for the first time.

Scope

  • Included:
    • .github/workflows/branch-cleanup.yml — the invalid workflows: write key removed; header comment corrected (it asserted the token needs that right).
    • .github/workflows/AUDIT.md — the row that recorded adding the key. Left alone it is a standing instruction to re-add it, which would re-break the file. It also called a prior NOTE "incorrect"; that NOTE was right, and this restores it.
  • Explicitly excluded: the restore path itself, the delete harness, and every other workflow. No logic changes.

The key never granted anything

Valid permissions: keys are actions, artifact-metadata, attestations, checks, code-quality, contents, deployments, discussions, id-token, issues, packages, pages, pull-requests, security-events, statuses, vulnerability-alerts. workflows is not among them.

The confusion behind it is reasonable. workflow is a real scope — for PATs and OAuth apps, and it is the one needed to push changes to files under .github/workflows/. It is simply not grantable to GITHUB_TOKEN through a permissions: block. So the original error message was genuine and the remedy was not available; the key granted nothing and cost the file its validity.

contents: write, which the archive-tag and branch-delete paths actually use, is untouched.

Verification

Head b8dc521f81e0c1 plus a merge of main (8cd4a10). The diff is unchanged at two files, +7/−3. Every result below read from the live API.

  • The fix is confirmed by the push that opened this PR — this is the direct evidence, not an inference. Pushing f81e0c1 to claude/clever-heisenberg-e6y7a1 created zero branch-cleanup runs. Before this commit, every push to every branch created one, because an unloadable workflow is reported regardless of its trigger filter. Now the file parses, on.push.branches: ["claude/branch-cleanup-*"] is honoured, and this branch does not match it — so nothing fires.

    That satisfies acceptance criterion 3 on ci: branch-cleanup.yml is an invalid workflow file (workflows is not a permission scope) — 1,163 consecutive failed runs on every push, including main #1405 outright, and it is a stronger test than criterion 1: a run named "Branch Cleanup" would only prove the file loads, whereas no run at all proves the file loads and its filters are being applied.

  • The file parses, re-checked at the merged head. yaml.safe_load resolves name: Branch Cleanup, permissions: {contents: write, pull-requests: read}, triggers workflow_dispatch + push. Zero keys outside the valid set.

  • The old failure was an unloadable file, not a failing job — three independent signatures, sampled across the 30 most recent runs before this change:

    Signature Observed
    Run name .github/workflows/branch-cleanup.yml — the path, not the name: field. GitHub falls back to the path when it cannot load the workflow.
    Duration created_at == run_started_at == updated_at, byte-identical. No job ever starts.
    Trigger filter Runs on main, groupthinking-fix-upstream-error-leakage, groupthinking-skill-dispatch-regression-tests — against a filter of only claude/branch-cleanup-*.
  • Scale, and it was still growing. 1,182 total runs, 100% failure, no successes in the workflow's entire history. Issue ci: branch-cleanup.yml is an invalid workflow file (workflows is not a permission scope) — 1,163 consecutive failed runs on every push, including main #1405 measured 1,163 two days ago; 19 more accumulated since, across 11 distinct branches in a single 30-run page.

  • Sole source. A survey of every permissions: block in .github/workflows/*.yml returns this key from branch-cleanup.yml and nowhere else.

  • Blast radius of enabling the workflow is zero. git ls-remote returns 0 of 342 branches matching claude/branch-cleanup-*, so no latent sentinel commit can fire now that the file parses. workflow_dispatch defaults to dry-run.

Checks — all complete, none failing

mergeable_state: clean.

Check Result
validate success
CI (guards, lint-python, lint-frontend, build, test) success
CodeQL success
gitleaks (working tree) success
dependency-review success
PR Governance success
Canonical issue and evidence success
Coverage success
Security Scan (python + javascript) success
validate-gh-aw success
trivy, bandit, python-safety, npm-audit success
E2E Pipeline Tests skipped

Vercel preview Ready; Vercel Agent Review success.

On the previously-red check. Earlier revisions of this section recorded agent-completion/truth-gate failing with invalid_payload, and named it the sole cause of mergeable_state: unstable. That check no longer exists#1431 retired the agent-completion truth gate and merged to main as 8cd4a10, which is merged into this branch. It does not appear in this head's check runs at all, and the merge state is now clean. The analysis in the comments below is retained as a record of what was true at the time, not as a live caveat.

Review: CodeRabbit ran a full review and returned no code findings, independently confirming the permission analysis, the unloadable-file diagnosis, and the restore-constraint framing. Zero review threads are open. It declined to approve for two stated reasons — the truth gate failing, and CI still in progress. Both are now resolved: the gate is retired and every check has completed.

Risk

  • Risk level: low. Deleting an ineffective key from a workflow that has never executed.
  • Failure mode: none on the delete path — archive-tagging and ref deletion need contents: write, retained.
  • Worth watching on the first real run: the restore path (git push origin "refs/tags/archive/$b:refs/heads/$b") recreates a ref whose tree may contain .github/workflows/ files. GITHUB_TOKEN cannot create or update those, and no permissions: key lifts that. If restore fails that way it is a pre-existing latent constraint this fix makes reachable for the first time — not a regression. It needs a PAT with the workflow scope, or a local push. The header comment now says so.
  • Rollback: revert this single commit.

Production evidence

Not applicable — CI configuration only, no runtime or deployable surface. The observable effect is on GitHub Actions itself, and it is already measurable: see the first verification item.

Agent handoff

Agent provenance

Agent-authored. No agent-lock-manifest is filled in. The manifest declared a run_id and agent_login that the truth gate treated as evidence expected to be corroborated by append-only result comments; there is no dispatch record behind this change, and fabricating those values would have injected false evidence into that mechanism. That gate has since been retired by #1431, so the field is now inert either way.

`workflows` is not a GitHub Actions permission scope. The valid keys are
`actions`, `artifact-metadata`, `attestations`, `checks`, `code-quality`,
`contents`, `deployments`, `discussions`, `id-token`, `issues`, `packages`,
`pages`, `pull-requests`, `security-events`, `statuses`, and
`vulnerability-alerts`. Declaring it made `branch-cleanup.yml` unparseable,
so GitHub rejected the file outright.

The result was a run created on every push to every branch, including main,
each one failing instantly. Verified against the workflow's own run history:
1,182 runs, 100% failure, no successes. The signature is that of an
unloadable file rather than a failing job -- runs are named by file path
(`.github/workflows/branch-cleanup.yml`) instead of by the `name:` field
("Branch Cleanup"), `created_at` / `run_started_at` / `updated_at` are
byte-identical so no job ever starts, and runs fire on branches the trigger
filter excludes (`main`, `groupthinking-fix-upstream-error-leakage`, and
others, against `on.push.branches: ["claude/branch-cleanup-*"]`).

Removing the key changes no effective permission -- an invalid key grants
nothing, and the workflow has never executed with it in force. `contents:
write`, which the archive-tag and delete paths actually need, is retained.

The confusion is understandable: `workflow` is a real scope for PATs and
OAuth apps. It is not grantable to GITHUB_TOKEN via `permissions:`. The
header comment and the `AUDIT.md` row that recorded the original change are
corrected to say so, since both would otherwise invite re-adding the key.
AUDIT.md also claimed the note about restoring workflow-containing branches
was incorrect; that note was right, and it is restored -- GITHUB_TOKEN
cannot create or update files under `.github/workflows/` regardless of the
permissions block, so that path needs a PAT with the `workflow` scope.

Closes #1405

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0145DzkZ1bsuJe3WktfWfyx8
@vercel

vercel Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
v0-uvai Ready Ready Preview, v0 Aug 7, 2026 8:47pm

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are limited based on label configuration.

🏷️ Required labels (at least one) (1)
  • [‘architecture-gap’, ‘bug’, ‘ci-cd’, ‘ci/cd’, ‘copilot-rabbit’, ‘documentation’, ‘duplicate’, ‘enhancement’, ‘frontend’, ‘github_actions’, ‘good first issue’, ‘help wanted’, ‘high-priority’, ‘invalid’, ‘javascript’, ‘ml-model’, ‘needs-triage’, ‘pipeline-critical’, ‘placeholder-code’, ‘priority:high’, ‘python’, ‘python:uv’, ‘question’, ‘styling’, ‘tests’, ‘v0’]

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository YAML (base), Repository UI (inherited), Organization UI (inherited)

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: f4a091a2-ab0d-4744-a889-33b3e1ecf276

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added ci/cd documentation Improvements or additions to documentation labels Aug 7, 2026
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Snapshot Warnings

⚠️: No snapshots were found for the head SHA 0ae7922.
Ensure that dependencies are being submitted on PR branches and consider enabling retry-on-snapshot-warnings. See the documentation for more information and troubleshooting advice.

Scanned Files

None

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

Agent Completion Truth Gate: BLOCKED

Reasons: invalid_payload

Machine-readable verdict
{
  "details": {
    "collection_errors": [
      "incomplete_linked_issue_contract",
      "linked_issue_not_agent_task",
      "missing_intent_snapshot",
      "missing_agent_run_id",
      "missing_agent_login"
    ],
    "invalid_fields": [
      "policy.agent_login",
      "policy.run_id"
    ]
  },
  "reasons": [
    "invalid_payload"
  ],
  "verdict": "blocked"
}

Workflow evidence

Copy link
Copy Markdown
Owner Author

@coderabbitai full review

The skip above was a label race, not a configuration mismatch — auto-label applied documentation and ci/cd after your run had already started, and both are in the required set.

Worth flagging what to focus on, since the diff is three lines and the interesting question is not in it: this PR deletes workflows: write from .github/workflows/branch-cleanup.yml on the claim that workflows is not a valid GitHub Actions permission scope, and that declaring it made the file unparseable rather than over-permissioned.

The claim is falsifiable in two directions and I would rather you check it than agree with it:

  1. If workflows were valid, removing it would be a permission regression on the restore path. The evidence it is not valid is behavioural rather than documentary — 1,182 runs, 100% failure, zero duration, runs named by file path instead of by the name: field, and runs firing on main against on.push.branches: ["claude/branch-cleanup-*"]. That last one is the strongest signal, since a valid file cannot produce a run on a branch its own filter excludes.
  2. The restore path may now fail differently. GITHUB_TOKEN cannot create or update files under .github/workflows/ no matter what the permissions block says. Once the file parses, git push origin "refs/tags/archive/$b:refs/heads/$b" becomes reachable for the first time and could fail on any archived branch whose tree contains workflow files. I have documented that in the header comment as a pre-existing latent constraint rather than treating it as fixed. If you think that framing understates it, say so.

Generated by Claude Code

Copy link
Copy Markdown
Owner Author

agent-completion/truth-gate — red, unfixable from this branch, not required

Recording this rather than leaving it as an unexplained red mark. It is not a defect in this diff and no commit here can clear it.

Why no commit on this branch can change it

.github/workflows/pr-checks.yml:4 triggers on pull_request_target, so the workflow runs from base, not from this head. agentTaskApplicable (line 1942) treats any PR whose head ref matches /^(?:agent|claude|codex|copilot|jules)[/-]/ as an agent dispatch. This branch is claude/clever-heisenberg-e6y7a1, so the full dispatch contract applies — including an agent-lock-intent-snapshot:v1 comment that must be authored by github-actions[bot] on the linked issue and must strictly predate the PR (snapshotPredatesPull, line 1933).

That snapshot is emitted by the dispatch workflow. #1405 was filed by hand and never dispatched, so no snapshot exists, and one written now would fail the predates-check even if it could be authored under that identity. The five collection_errors in the verdict — missing_intent_snapshot, missing_agent_run_id, missing_agent_login, linked_issue_not_agent_task, incomplete_linked_issue_contract — all follow from that single absence.

The two invalid_fields (policy.agent_login, policy.run_id) are the deliberately-empty agent-lock-manifest. Filling them would mean inventing a run_id and agent_login that the gate treats as corroborated evidence. That injects false evidence into the mechanism rather than satisfying it, so the section stays empty and the gate stays red.

Why it does not block

MERGE_POLICY.md gate 2 (line 83) lists the required checks explicitly:

validate, guards, lint-python, lint-frontend, build, test, CodeQL, gitleaks (working tree), dependency-review, PR Governance, Canonical issue and evidence

agent-completion/truth-gate is not among them, and not in the conditional list either.

The same file names this check as its own worked example of the failure mode it exists to prevent (line 162):

agent-completion/truth-gate was red on ~100% of pull requests for weeks, including merged ones, and nobody noticed because everyone had learned to ignore it.

That is corroborated by merge history rather than by the document alone: #1408 and #1400 both merged with this check red. #1409 is the open fix for the arming defect.

Status of the rest

dependency-review green. Vercel preview Ready. CodeRabbit reports success (review skipped on label configuration — I have requested an explicit pass above; the skip is not a failure state).

No fix is being pushed for this check because none is possible from here, and none is warranted.


Generated by Claude Code

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor
🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '=== PR metadata and changed files ==='
gh pr view 1420 --repo groupthinking/EventRelay \
  --json number,title,headRefOid,baseRefName,headRefName,mergeable,mergeStateStatus,files,statusCheckRollup \
  --jq '{number,title,headRefOid,baseRefName,headRefName,mergeable,mergeStateStatus,files:[.files[]|{path,additions,deletions}],checks:[.statusCheckRollup[]|{name,conclusion,status}]}'

echo '=== PR diff ==='
gh pr diff 1420 --repo groupthinking/EventRelay -- .github/workflows/branch-cleanup.yml .github/workflows/AUDIT.md

echo '=== Workflow outline and relevant sections ==='
ast-grep outline .github/workflows/branch-cleanup.yml --items all || true
cat -n .github/workflows/branch-cleanup.yml
echo '=== Audit references ==='
rg -n -C 5 'branch-cleanup|workflows: write|workflow' .github/workflows/AUDIT.md || true

echo '=== Every workflow permissions key ==='
python3 - <<'PY'
from pathlib import Path
import re
for path in sorted(Path(".github/workflows").glob("*.yml")):
    text = path.read_text()
    m = re.search(r'(?ms)^permissions:\s*\n((?:^[ \t]+.*\n?)*)', text)
    if m:
        print(f"--- {path}")
        print(m.group(0).rstrip())
PY

echo '=== Recent branch-cleanup workflow runs ==='
gh api 'repos/groupthinking/EventRelay/actions/workflows/branch-cleanup.yml/runs?per_page=30' \
  --jq '.workflow_runs[] | {id,name,head_branch,event,status,conclusion,created_at,run_started_at,updated_at,path}'

echo '=== Current PR check runs ==='
gh pr checks 1420 --repo groupthinking/EventRelay --json name,state,workflow,link,bucket 2>&1 || true

Length of output: 4743


🤖 web_search executed:

GitHub Docs: GITHUB_TOKEN permissions supported permission keys workflow files push restriction personal access token workflow scope

Length of output: 14987


🏁 Script executed:

#!/bin/bash
set -u -o pipefail

echo '=== Changed-file patches from GitHub API ==='
gh api 'repos/groupthinking/EventRelay/pulls/1420/files?per_page=100' \
  --jq '.[] | select(.filename==".github/workflows/branch-cleanup.yml" or .filename==".github/workflows/AUDIT.md") | {filename,status,patch}'

echo '=== branch-cleanup workflow at PR head ==='
cat -n .github/workflows/branch-cleanup.yml || true

echo '=== Relevant audit entries ==='
rg -n -C 6 'branch-cleanup|workflows: write|workflow' .github/workflows/AUDIT.md || true

echo '=== YAML permissions validation ==='
python3 - <<'PY'
from pathlib import Path
import re
valid = {
    "actions", "artifact-metadata", "attestations", "checks", "code-scanning",
    "contents", "deployments", "discussions", "id-token", "issues", "models",
    "packages", "pages", "pull-requests", "security-events", "statuses",
}
path = Path(".github/workflows/branch-cleanup.yml")
text = path.read_text()
m = re.search(r'(?ms)^permissions:\s*\n((?:^[ \t]+.*\n?)*)', text)
keys = []
if m:
    keys = re.findall(r'(?m)^[ \t]+([A-Za-z][\w-]*):', m.group(1))
print({"permissions_keys": keys, "invalid_keys": sorted(set(keys) - valid)})
print({"has_workflows_key": bool(re.search(r'(?m)^[ \t]*workflows\s*:', text))})
print({"has_restore_push": 'refs/tags/archive/$b:refs/heads/$b' in text})
PY

echo '=== All workflow permissions blocks ==='
python3 - <<'PY'
from pathlib import Path
import re
for path in sorted(Path(".github/workflows").glob("*.yml")):
    text = path.read_text()
    m = re.search(r'(?ms)^permissions:\s*\n((?:^[ \t]+.*\n?)*)', text)
    if m:
        print(f"--- {path}")
        print(m.group(0).rstrip())
PY

echo '=== Latest 30 branch-cleanup runs ==='
gh api 'repos/groupthinking/EventRelay/actions/workflows/branch-cleanup.yml/runs?per_page=30' \
  --jq '.workflow_runs[] | {id,name,head_branch,event,status,conclusion,created_at,run_started_at,updated_at,path}'

echo '=== PR check-run details, including failed checks ==='
gh api 'repos/groupthinking/EventRelay/commits/f81e0c19da000f49c6ae33342e26248aa889f03d/check-runs?per_page=100' \
  --jq '.check_runs[] | select(.conclusion=="failure" or .status!="completed") | {name,status,conclusion,details_url,output}'

Length of output: 132137


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '=== Exact workflow permissions block and YAML parse ==='
python3 - <<'PY'
from pathlib import Path
import yaml

path = Path(".github/workflows/branch-cleanup.yml")
document = yaml.safe_load(path.read_text())
permissions = document["permissions"]
valid = {
    "actions", "artifact-metadata", "attestations", "checks", "contents",
    "deployments", "discussions", "id-token", "issues", "models", "packages",
    "pages", "pull-requests", "security-events", "statuses",
}
print({
    "name": document["name"],
    "triggers": sorted(document[True].keys()),
    "permissions": permissions,
    "invalid_permission_keys": sorted(set(permissions) - valid),
    "restore_command": document["jobs"]["cleanup"]["steps"][2]["run"].splitlines()[1].strip(),
})
PY

echo '=== Failed truth-gate annotations ==='
gh api 'repos/groupthinking/EventRelay/check-runs/92953636607/annotations?per_page=100' \
  --jq '.[] | {path,start_line,end_line,annotation_level,message,title}'

echo '=== Workflow record ==='
gh api 'repos/groupthinking/EventRelay/actions/workflows/branch-cleanup.yml' \
  --jq '{id,name,path,state,created_at,updated_at}'

echo '=== GitHub Docs: workflow syntax permission allowlist ==='
curl -fsSL \
  'https://raw.githubusercontent.com/github/docs/main/content/actions/writing-workflows/workflow-syntax-for-github-actions.md' \
  | awk '
      /^### `permissions`/{show=1}
      show {print}
      show && /^### `/ && $0 !~ /^### `permissions`/ {exit}
    ' \
  | grep -E '^(###|####)|`(actions|artifact-metadata|attestations|checks|contents|deployments|discussions|id-token|issues|models|packages|pages|pull-requests|security-events|statuses)`' \
  | head -100

echo '=== GitHub Docs: pushing workflow-file changes ==='
curl -fsSL \
  'https://raw.githubusercontent.com/github/docs/main/content/rest/repos/contents.md' \
  | grep -in -A8 -B4 'workflow' \
  | head -80

Length of output: 2392


🏁 Script executed:

#!/bin/bash
set -u -o pipefail

echo '=== Exact permissions mapping by YAML indentation ==='
awk '
  /^permissions:$/ { in_permissions=1; next }
  in_permissions && /^[^[:space:]]/ { in_permissions=0 }
  in_permissions && /^  [A-Za-z][A-Za-z0-9-]*:/ { print }
' .github/workflows/branch-cleanup.yml
echo '=== Workflow trigger and restore command lines ==='
grep -nE '^(name:|on:|  workflow_dispatch:|  push:|    branches:|permissions:|  contents:|  pull-requests:|          git push origin)' \
  .github/workflows/branch-cleanup.yml

echo '=== Failed truth-gate annotations ==='
gh api 'repos/groupthinking/EventRelay/check-runs/92953636607/annotations?per_page=100' \
  --jq '.[] | {path,start_line,end_line,annotation_level,message,title}' || true

echo '=== Workflow record ==='
gh api 'repos/groupthinking/EventRelay/actions/workflows/branch-cleanup.yml' \
  --jq '{id,name,path,state,created_at,updated_at}' || true

echo '=== GitHub Docs: workflow syntax permission allowlist ==='
curl -fsSL \
  'https://raw.githubusercontent.com/github/docs/main/content/actions/writing-workflows/workflow-syntax-for-github-actions.md' \
  | grep -n -E -A2 -B2 '`(actions|artifact-metadata|attestations|checks|contents|deployments|discussions|id-token|issues|models|packages|pages|pull-requests|security-events|statuses)`' \
  | head -120 || true

echo '=== GitHub Docs: workflow-file changes require workflow authority ==='
curl -fsSL \
  'https://raw.githubusercontent.com/github/docs/main/content/rest/repos/contents.md' \
  | grep -in -A10 -B5 'workflow' \
  | head -120 || true

Length of output: 2648


@groupthinking Full review complete. I found no code findings.

  • The updated permissions mapping contains only contents: write and pull-requests: read. It no longer declares workflows.
  • The historical run data supports the unloadable-workflow diagnosis. The runs use .github/workflows/branch-cleanup.yml as the run name, start and complete at the creation timestamp, and occur on excluded branches such as main.
  • Removing workflows: write does not reduce an available GITHUB_TOKEN permission. The key is not a valid GitHub Actions workflow permission key.
  • contents: write remains available for the archive-tag and branch-delete paths.
  • The header comment correctly identifies the restore constraint. Once this workflow loads, a restore can reach git push origin "refs/tags/archive/$b:refs/heads/$b". If the restored ref changes files in .github/workflows/, GITHUB_TOKEN cannot authorize that update. A token with the required workflow-file authority, or a local push, is required. This is a reachable pre-existing constraint, not a regression from this change.

I cannot approve yet. agent-completion/truth-gate currently fails with “Agent completion evidence is blocked,” and several CI checks remain in progress. The review itself has no requested changes.

🐇🔎


✏️ Learnings added
Learnt from: groupthinking
URL: https://github.com/groupthinking/EventRelay/pull/1420

Timestamp: 2026-08-07T18:04:29.352Z
Learning: In `.github/workflows/branch-cleanup.yml`, `workflows` is not a valid GitHub Actions `permissions:` key for `GITHUB_TOKEN`. The branch cleanup workflow requires `contents: write` for archive tagging and branch deletion. Its restore command can recreate a branch from `archive/<branch>`, but `GITHUB_TOKEN` cannot authorize a push that creates or updates `.github/workflows/` files; that restore case requires separate workflow-file authority or a local push.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

⚠️ Action not completed

Review rate limited.


Your included review limit is currently reached under our Fair Usage Limits Policy. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 6 minutes.

Copy link
Copy Markdown
Owner Author

Red-team pass on the diff

The interesting risk here is not in the three lines removed. It is that this change makes a branch-deletion workflow executable for the first time in its history. A fix that takes a destructive workflow from "never runs" to "runs" deserves more scrutiny than its diff size suggests, so I went looking for a way it could fire unintentionally.

Conclusion: it cannot, and the blast radius today is zero. Three independent gates, each verified against the file rather than the header comment.

1. The push-sentinel path has no trigger surface at all

on.push.branches: ["claude/branch-cleanup-*"]. I enumerated the repository's branches: no branch matching that pattern exists. The alphabetical window is fully covered by the first page — claude/automation-repository-drift-report is followed directly by claude/clever-heisenberg-0zuq9k, and claude/branch-cleanup-* would sort between them. Nothing there.

This was the failure mode I was actually hunting. Because the workflow has always failed instantly, someone could plausibly have pushed a [run-cleanup:safe] sentinel commit at some point and moved on, knowing nothing would happen — and this fix would then execute it on the next push. There is no such branch, so there is no such latent commit.

2. Sentinel matching is explicit, and the default is a no-op

steps.decide reads only the tip commit message (git log -1 --pretty=%B) and requires a literal [run-cleanup:(safe|stale|review)] or [restore-branch:<name>]. Anything else sets go=no and both action steps are gated on go == 'yes'. An ordinary push to a future claude/branch-cleanup-* branch does nothing.

Worth stating plainly since the header comment does not: the sentinel path hard-codes dry=0. A sentinel commit is a real delete, with no dry-run. That is the documented design, not a defect, but it means the only safety between a sentinel and deletion is the delete script's own guards — a live open-PR check per branch and an archive/<branch> tag pushed before removal.

3. workflow_dispatch defaults to dry-run

dry_run is type: boolean, required: true, default: true, and maps to dry=1. Deleting requires a human to open the Actions UI and actively flip it to false.

The permission question, tested in the falsifying direction

If workflows were a real scope, removing it would be a permission regression on the restore path. The strongest evidence it is not comes from behaviour rather than documentation: runs were being created on main against a filter of only claude/branch-cleanup-*. A valid workflow file cannot produce a run on a branch its own trigger excludes. That observation is only explicable if GitHub never parsed the file — which is also why the runs were named by file path instead of by name:, and why all three timestamps were byte-identical.

The push that opened this PR confirms it from the other side: f81e0c1 created zero branch-cleanup runs, where every previous push to every branch created one.

One thing this fix does not solve

GITHUB_TOKEN cannot create or update files under .github/workflows/, and no permissions: key lifts that. The restore path (git push origin "refs/tags/archive/$b:refs/heads/$b") may therefore still fail on any archived branch whose tree contains workflow files — which, in this repository, is most of them. That constraint is pre-existing and was previously unreachable because the workflow never ran; this change makes it reachable. It is documented in the header comment rather than papered over, and it needs a PAT with the workflow scope or a local push to resolve. Flagging it as a known limitation, not a regression.


Generated by Claude Code

@groupthinking
groupthinking marked this pull request as ready for review August 7, 2026 18:06

Copy link
Copy Markdown
Owner Author

Green gate reached — HALTED(awaiting_merge_approval)

All required checks pass on f81e0c1. Marked ready for review; the only remaining step is a human merge.

Required checks, per MERGE_POLICY.md gate 2

Check Result
validate success
CI (guards, lint-python, lint-frontend, build, test) success
CodeQL success
gitleaks (working tree) success
dependency-review success
PR Governance success
Canonical issue and evidence success

Conditionally required, and reporting green rather than pending: Coverage success, E2E Pipeline Tests skipped. Also green: Security Scan (python + javascript), gh-aw Validation, Agent completion enforcement, Auto Label. Vercel preview Ready.

Not green: agent-completion/truth-gate, invalid_payload. Not in the required list, unfixable from this branch — full analysis above. It has now re-fired three times on this PR (push, body edit, ready-for-review) with a byte-identical verdict each time, which is itself a small demonstration of the point MERGE_POLICY.md:157 makes about checks that carry no signal.

Review

CodeRabbit ran a full review and returned no code findings, confirming each substantive claim independently — that the permissions mapping no longer declares workflows, that the historical run data supports the unloadable-file diagnosis, that removing the key reduces no available GITHUB_TOKEN permission, and that the restore constraint is a reachable pre-existing limitation rather than a regression from this change. Zero review threads are open.

The fix is already demonstrably working

Worth restating because it is verifiable from this PR's own run history rather than from anything I assert: this branch has produced zero branch-cleanup runs. Every push to every other branch still produces one instantly-failed run. git ls-remote confirms 0 of 342 branches match the claude/branch-cleanup-* trigger, so once this lands the workflow should stop generating runs entirely until someone deliberately creates such a branch or dispatches it.

Staged merge command

gh pr merge 1420 --squash --repo groupthinking/EventRelay

Not merging automatically: this PR carries no automerge label, and main is protected.


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

@coderabbitai full review

Auto-review skipped on this head for want of a qualifying label, so requesting explicitly. Worth flagging that this PR's body asserts a clean CodeRabbit full review with no code findings — as of f81e0c1 there are no submitted reviews on this PR at all, and the CodeRabbit commit status reads Review skipped: excluded by label configuration. That claim is unsupported; this comment is the first real review request.

The diff is two files, +7/−3. The claim to test hardest is the central one, because the whole change rests on it: workflows is not a valid permissions: key, and declaring it made .github/workflows/branch-cleanup.yml unloadable rather than merely under-permissioned. If that is wrong — if the file was parseable and the 1,182 failures had some other cause — then deleting the key is not the fix and the AUDIT.md edit is wrong with it.

Two secondary claims also worth a skeptical pass:

  1. That removing the key loses no effective permission. contents: write is retained and is what the archive-tag and branch-delete paths use. If any path in the workflow actually needs something the deleted key was standing in for, that should surface now.
  2. That the restore path (git push origin "refs/tags/archive/$b:refs/heads/$b") has a pre-existing constraint rather than one this change introduces — GITHUB_TOKEN cannot create or update files under .github/workflows/ regardless of the permissions block. The header comment now says so. If that framing is wrong, the comment is misleading in a way that will cost someone a debugging session on the first real run.

agent-completion/truth-gate is red here with invalid_payload. Per MERGE_POLICY.md gate 2 it is not a required check; #1409 repairs the arming rule. Not asking you to work around it — flagging so it does not read as an unaddressed failure.


Generated by Claude Code

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

repo="groupthinking/EventRelay"
pr="1420"

echo '=== PR metadata, files, and checks ==='
gh pr view "$pr" --repo "$repo" \
  --json number,title,headRefOid,headRefName,baseRefName,mergeable,mergeStateStatus,files,statusCheckRollup \
  --jq '{
    number,title,headRefOid,headRefName,baseRefName,mergeable,mergeStateStatus,
    files:[.files[]|{path,additions,deletions}],
    checks:[.statusCheckRollup[]|{name,conclusion,status}]
  }'

echo '=== PR patches ==='
gh api "repos/$repo/pulls/$pr/files?per_page=100" --jq '
  .[] | select(.filename==".github/workflows/branch-cleanup.yml" or .filename==".github/workflows/AUDIT.md")
  | {filename,status,patch}'

echo '=== branch-cleanup workflow structure ==='
ast-grep outline .github/workflows/branch-cleanup.yml --items all || true
cat -n .github/workflows/branch-cleanup.yml

echo '=== Relevant AUDIT.md context ==='
rg -n -C 8 'branch-cleanup|workflows: write|workflow permission|permission' .github/workflows/AUDIT.md || true

echo '=== Parse permissions and identify top-level workflow properties ==='
python3 - <<'PY'
from pathlib import Path
import yaml
p = Path(".github/workflows/branch-cleanup.yml")
d = yaml.safe_load(p.read_text())
valid = {
    "actions", "artifact-metadata", "attestations", "checks", "contents",
    "deployments", "discussions", "id-token", "issues", "models", "packages",
    "pages", "pull-requests", "security-events", "statuses",
}
print({
    "name": d.get("name"),
    "triggers": sorted((d.get(True) or {}).keys()),
    "permissions": d.get("permissions"),
    "invalid_permission_keys": sorted(set(d.get("permissions", {})) - valid),
})
PY

echo '=== Exact delete and restore controls ==='
rg -n -C 5 '\[run-cleanup:|\[restore-branch:|dry_run|git push origin|archive/' \
  .github/workflows/branch-cleanup.yml

echo '=== Recent branch-cleanup runs ==='
gh api "repos/$repo/actions/workflows/branch-cleanup.yml/runs?per_page=30" --jq '
  .workflow_runs[] |
  {id,name,head_branch,event,status,conclusion,created_at,run_started_at,updated_at,path,head_sha}'

echo '=== Current PR branch-cleanup runs ==='
head_sha=$(gh pr view "$pr" --repo "$repo" --json headRefOid --jq .headRefOid)
gh api "repos/$repo/actions/workflows/branch-cleanup.yml/runs?head_sha=$head_sha&per_page=100" --jq '
  .workflow_runs[] | {id,name,head_branch,event,status,conclusion,created_at,run_started_at,updated_at,path}' || true

echo '=== Official GitHub Docs: supported permissions keys ==='
curl -fsSL 'https://raw.githubusercontent.com/github/docs/main/content/actions/writing-workflows/workflow-syntax-for-github-actions.md' |
  sed -n '/^### `permissions`/,/^### /p' |
  grep -E '^#### `|^### `permissions`' || true

echo '=== Official GitHub Docs: workflow-file update authentication restriction ==='
curl -fsSL 'https://raw.githubusercontent.com/github/docs/main/content/rest/repos/contents.md' |
  grep -in -A10 -B5 'workflow' | head -100 || true

Length of output: 23910


🏁 Script executed:

#!/bin/bash
set -u -o pipefail

repo="groupthinking/EventRelay"
pr="1420"
head_sha="f81e0c19da000f49c6ae33342e26248aa889f03d"

echo '=== Exact permission block ==='
awk '
  /^permissions:$/ {active=1; print; next}
  active && /^[^[:space:]]/ {active=0}
  active {print}
' .github/workflows/branch-cleanup.yml

echo '=== Delete harness interface and safety-related operations ==='
fd -a '^branch-cleanup-delete\.sh$' . |
  while IFS= read -r path; do
    echo "--- $path"
    grep -nE '^(#!/|set -)|DRY_RUN|GITHUB_TOKEN|archive/|git (push|branch)|pull request|open PR|gh pr|delete' "$path" || true
  done

echo '=== Recent branch-cleanup workflow runs ==='
gh api "repos/$repo/actions/workflows/branch-cleanup.yml/runs?per_page=30" --jq '
  .workflow_runs[] |
  {id,name,head_branch,event,status,conclusion,created_at,run_started_at,updated_at,path,head_sha}' || true

echo '=== branch-cleanup workflow runs for the PR head ==='
gh api "repos/$repo/actions/workflows/branch-cleanup.yml/runs?head_sha=$head_sha&per_page=100" --jq '
  {total_count, runs:[.workflow_runs[] | {id,name,head_branch,event,status,conclusion,created_at,run_started_at,updated_at,path}]}' || true

echo '=== Workflow record as recognized by GitHub ==='
gh api "repos/$repo/actions/workflows/branch-cleanup.yml" --jq \
  '{id,name,path,state,created_at,updated_at}' || true

echo '=== Official GitHub Actions workflow-syntax documentation ==='
curl -fsSL 'https://raw.githubusercontent.com/github/docs/main/content/actions/writing-workflows/workflow-syntax-for-github-actions.md' |
  awk '
    /^### `permissions`/{show=1}
    show {print}
    show && /^### `/ && $0 != "### `permissions`" {exit}
  ' |
  grep -E '^### |^#### |`(actions|artifact-metadata|attestations|checks|contents|deployments|discussions|id-token|issues|models|packages|pages|pull-requests|security-events|statuses)`' ||
  true

echo '=== Official GitHub REST documentation for workflow-file updates ==='
curl -fsSL 'https://raw.githubusercontent.com/github/docs/main/content/rest/repos/contents.md' |
  grep -in -A14 -B5 'workflows' | head -160 ||
  true

echo '=== PR reviews and CodeRabbit statuses ==='
gh pr view "$pr" --repo "$repo" --json reviews,statusCheckRollup --jq '
  {
    reviews:[.reviews[] | {author:.author.login,state,submittedAt,body}],
    coderabbit_statuses:[
      .statusCheckRollup[] |
      select((.name // "" | test("CodeRabbit"; "i")) or (.context // "" | test("CodeRabbit"; "i"))) |
      {name,context,conclusion,status,description,targetUrl}
    ]
  }' || true

Length of output: 16344


🤖 web_search executed:

`site:docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions "Available permissions" "contents" "pull-requests"

site:docs.github.com "workflow files" "personal access token" "workflow scope" "GitHub App"`

Length of output: 13950


@groupthinking I need one more authoritative source for the GITHUB_TOKEN permission allowlist. The search results confirm that GitHub Apps have a separate Workflows repository permission, but they do not yet provide the GitHub Actions permissions: allowlist.

You are interacting with an AI system.

Copy link
Copy Markdown
Owner Author

The allowlist, from primary source

Good question to insist on, and there's a concrete reason your extraction came up empty.

Why your sed/awk passes over workflow-syntax.md found nothing. That page does not contain the enumerated list. It contains a Liquid include:

{% data reusables.actions.github-token-available-permissions %}

So sed -n '/^### \permissions`/,/^### /p'` correctly extracted the section and the section correctly contained no keys. The list lives in the interpolated reusable, one file over.

The canonical file is data/reusables/actions/github-token-available-permissions.md. Its complete set, 16 keys:

actions · artifact-metadata · attestations · checks · code-quality · contents · deployments · discussions · id-token · issues · packages · pages · pull-requests · security-events · statuses · vulnerability-alerts

I confirmed the same list independently against the rendered page at docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax, where the reusable is expanded. Both agree, and neither contains workflows.

Your GitHub Apps finding is right, and it is the source of the confusion

You found that GitHub Apps have a separate Workflows repository permission. That is real, and it is exactly why this bug was written in the first place — the original error message ("refusing to allow a GitHub App to create or update workflow ... without workflows permission") is genuine and names a real capability.

The trap is that the two live in different systems:

Where it is granted What it controls
GitHub App Workflows permission App installation settings whether that App may write files under .github/workflows/
Workflow permissions: block the workflow YAML scopes minted onto GITHUB_TOKEN for that run

The permissions: block cannot reach the first. Writing workflows: write there does not request the App permission — it just puts an unrecognised key in a schema-validated position.

The strongest evidence is not documentary

Worth stating, because it settles the question without depending on how anyone reads a docs page: GitHub's own parser already adjudicated this.

An invalid permissions: key does not produce an over-permissioned run — it makes the file unloadable. The three signatures follow from that and are all in this PR's run history:

  • runs named .github/workflows/branch-cleanup.yml rather than Branch Cleanup, the documented fallback when a workflow cannot be loaded;
  • created_at == run_started_at == updated_at, byte-identical — no job ever started;
  • runs created on main against on.push.branches: ["claude/branch-cleanup-*"]. A valid workflow file cannot produce a run on a branch its own trigger filter excludes.

1,182 runs, 100% failure, zero successes across the workflow's entire history.

And the confirming half, from this PR: pushing f81e0c1 with the key removed created zero branch-cleanup runs. Same repository, same branch-naming, opposite result — which is only explicable if the file is now being parsed and its filters applied.

If workflows were a valid key, none of those five observations would hold.


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

Correction — my review request above was wrong on its premise

I claimed the body's "CodeRabbit ran a full review and returned no code findings" was unsupported. It is supported. CodeRabbit posted exactly that at 18:04 (comment 5220456048) — "Full review complete. I found no code findings" — with all four substantive claims verified independently.

What misled me: it delivered the result as an issue comment rather than a submitted review object, so pulls/1420/reviews returns [], and the CodeRabbit commit status still reads Review skipped: excluded by label configuration because that status reflects the auto-review path, which the inherited label gate did skip. Two different mechanisms, and I read the absence of the first as proof no review happened. The review gate on this PR was already satisfied before I asked.

Two costs worth recording rather than quietly absorbing:

  1. The re-request consumed a rate-limited review. CodeRabbit's 18:04 comment notes the included-review limit was already reached under Fair Usage. My duplicate request spent allowance for a review that had already been done and returned nothing new — it only pulled CodeRabbit into a docs-sourcing sub-thread.
  2. A concurrent session is already driving this PR. The 18:14 reply supplying the canonical github-token-available-permissions.md allowlist landed while I was mid-scan. I was duplicating in-flight work, not adding to it.

Nothing in the diff changes and no finding is outstanding. The get_reviews-returns-empty signal is worth knowing generally: on this repo a clean CodeRabbit pass leaves no review object and a skipped status, so neither is evidence of an unreviewed head. That combination is a direct consequence of the inherited label gate #1425 fixes.

Terminal state unchanged: HALTED(awaiting_merge_approval).


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

Head moved to b8dc521 — the one red check no longer exists

main was merged into this branch, bringing in 8cd4a10 (#1431), which retired agent-completion/truth-gate outright. The verification section above was written against f81e0c1 and describes that check as red-but-not-required; that framing is now obsolete. It is not red — it is gone, absent from all 25 check runs on this head rather than reporting a verdict.

This PR now has zero failing checks.

Green validate, guards, lint-python, lint-frontend, build, PR Governance, Canonical issue and evidence, gitleaks (working tree), dependency-review, validate-gh-aw, bandit, npm-audit, python-safety, Security Scan - python, Vercel Agent Review, Vercel Preview Comments
Still running test, trivy, Generate and Upload Coverage, Security Scan - javascript
Skipped E2E Pipeline Tests, merge, approve
Neutral CodeQL, Trivy (the capitalised duplicate #1410 documents; lowercase trivy is the real job)
Failing none

The fix survived the merge intact — checked, not assumed

main's own changes touched AUDIT.md in the same commit, so I verified rather than trusting a clean merge exit code:

  • permissions: resolves to exactly {contents: write, pull-requests: read} — no invalid key.
  • grep 'workflows: write' over the workflow returns nothing.
  • The Correction (#1405) row in AUDIT.md survived, and merged alongside main's edits to neighbouring rows rather than clobbering them.
  • The header comment explaining why the scope is invalid is present.

git diff origin/main for the two files this PR owns is +9/−6 — the original change, nothing more.

Note for whoever merges

Vercel is rebuilding on the new head and will report Ready; it does not gate this change either way, since gate 4 scopes previews to apps/web/** and this diff touches only .github/.

Unchanged from before: the branch-cleanup workflow has produced zero runs on this branch across both heads, which remains the direct evidence that the file now parses and honours its trigger filter.


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

Blocker cleared — this PR is now fully green

The agent-completion/truth-gate failure described in the PR body is gone, and not by anything in this branch.

main retired the gate in #1431 (8cd4a10). Because pr-checks.yml runs on pull_request_target — executing from base, exactly as the body noted — retiring it on main removed it from every open PR at once. This branch has been synced onto 8cd4a10 (head now b8dc521) and the check no longer exists on the head commit; it did not merely flip to green.

Checks on b8dc5212

Zero failures across all 25 check runs. validate, guards, lint-python, lint-frontend, build, test, CodeQL, gitleaks (working tree), dependency-review, PR Governance, Canonical issue and evidence, validate-gh-aw, bandit, python-safety, npm-audit, trivy, Security Scan - python all success. E2E Pipeline Tests skipped; Vercel preview and agent review success.

The body's "Not green" section and its claim that the red check is "unfixable from this branch" are now stale — correct when written, overtaken by #1431.

The remaining gate is merge approval, which is a human decision and deliberately not taken here.


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

mergeable_state: clean — the last red check was retired, not worked around

Every check on b8dc521 has now completed. None is failing.

The caveat in my earlier status comment is void. It recorded agent-completion/truth-gate as red with invalid_payload, unfixable from this branch, and the sole cause of mergeable_state: unstable. #1431 retired that gate entirely and merged to main as 8cd4a10, which is merged into this branch — the check does not appear in this head's check runs at all, and the merge state is now clean. I've corrected the PR body, which was still asserting a red check and an unstable state to anyone reading it to decide on a merge.

Re-verified at the merged head rather than assumed from the earlier run, since a main merge could in principle have touched the file:

name: Branch Cleanup
permissions: {'contents': 'write', 'pull-requests': 'read'}
invalid keys: []
triggers: ['push', 'workflow_dispatch']

Diff unchanged: two files, +7/−3.

On the review

CodeRabbit's full review returned no code findings and confirmed each substantive claim independently. It declined to approve for two explicitly stated reasons — the truth gate failing, and several CI checks still in progress. Both conditions are now gone. Zero review threads are open.

Terminal state: HALTED(awaiting_merge_approval)

gh pr merge 1420 --squash --repo groupthinking/EventRelay

Not merging automatically: no automerge label, and main is protected. This is the only step left.


Generated by Claude Code

@groupthinking
groupthinking enabled auto-merge (squash) August 7, 2026 20:45
@groupthinking
groupthinking merged commit d9bab00 into main Aug 7, 2026
20 of 23 checks passed
@groupthinking
groupthinking deleted the claude/clever-heisenberg-e6y7a1 branch August 7, 2026 20:45
@linear-code

linear-code Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

GRV-366

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cd documentation Improvements or additions to documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ci: branch-cleanup.yml is an invalid workflow file (workflows is not a permission scope) — 1,163 consecutive failed runs on every push, including main

2 participants